SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
12.7 KB · 272 lines tsx
Raw Blame History
1import { ExternalLink } from 'lucide-react';2import type { Metadata } from 'next';3import Link from 'next/link';4import { notFound } from 'next/navigation';5import { BreadcrumbLd, Breadcrumbs } from '@/components/meta/breadcrumb-ld';6import { Chip, ConfidenceBadge, EntityBadge, TierBadge } from '@/components/ui/badges';7import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';8import { Hint } from '@/components/ui/hint';9import { Container, Note, PageHeader, Section } from '@/components/ui/section';10import { Unavailable } from '@/components/ui/unavailable';11import { ApiError, apiD3 } from '@/lib/api';12import { fmtDateTime, fmtInt, fmtValue, hostOf } from '@/lib/format';13import { propertyLabel, routes, SITE_NAME } from '@/lib/site';14import type { ClaimDetail, ClaimRow } from '@/lib/types';1516type Params = { params: Promise<{ id: string }> };17const STATUS_TONE: Record<string, string> = { current: 'text-positive bg-positive-soft', superseded: 'text-ink-2 bg-surface-2', conflicting: 'text-danger bg-danger-soft', retracted: 'text-warning bg-warning-soft' };1819async function loadClaim(id: string): Promise<ClaimDetail | null> {20  try {21    return await apiD3.claim(id);22  } catch (e) {23    if (e instanceof ApiError && e.notFound) notFound();24    return null;25  }26}2728export async function generateMetadata({ params }: Params): Promise<Metadata> {29  const { id } = await params;30  let c: ClaimDetail | null = null;31  try {32    c = await apiD3.claim(id);33  } catch {34    c = null;35  }36  const title = c ? `${c.entity?.name ?? 'Entity'} · ${propertyLabel(c.property)} = ${fmtValue(c.claim.value, c.property)} — claim` : 'Claim';37  return { title, description: 'One temporal claim of the AI Atlas graph: value, source, tier, extractor, validity interval and its lifecycle (previous, superseding and conflicting claims).', alternates: { canonical: routes.claim(id) }, robots: { index: false, follow: true } };38}3940function StatusChip({ s }: { s: string }) {41  return <span className={`inline-flex items-center rounded-[3px] px-1.5 py-[1px] text-[11px] font-medium ${STATUS_TONE[s] ?? 'text-ink-2 bg-surface-2'}`}>{s}</span>;42}4344function ClaimTable({ rows, property, currentId, caption }: { rows: ClaimRow[]; property: string; currentId: string; caption: string }) {45  return (46    <DataTable caption={caption} compact>47      <thead>48        <tr>49          <Th>Value</Th>50          <Th>Status</Th>51          <Th>Valid from</Th>52          <Th>Valid to</Th>53          <Th>Source</Th>54          <Th>Tier</Th>55          <Th>Extractor</Th>56          <Th>Claim</Th>57        </tr>58      </thead>59      <tbody>60        {rows.length === 0 && <EmptyRow cols={8}>None.</EmptyRow>}61        {rows.map((c) => (62          <tr key={c.id} className={c.id === currentId ? 'bg-accent-soft/40' : undefined}>63            <Td primary className="tnum">{fmtValue(c.value, property)}{c.unit ? <span className="text-ink-3"> {c.unit}</span> : null}</Td>64            <Td label="Status">65              <StatusChip s={c.status} />66            </Td>67            <Td label="Valid from" className="tnum text-xs text-ink-2">{fmtDateTime(c.valid_from)}</Td>68            <Td label="Valid to" className="tnum text-xs text-ink-2">{c.valid_to ? fmtDateTime(c.valid_to) : <span className="text-positive">open</span>}</Td>69            <Td label="Source" className="text-xs">70              {c.source_url ? (71                <a href={c.source_url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-ink-2 hover:text-accent">72                  {c.source_name ?? hostOf(c.source_url) ?? 'source'} <ExternalLink className="size-3" aria-hidden />73                </a>74              ) : (75                <span className="text-ink-3">—</span>76              )}77            </Td>78            <Td label="Tier">79              <TierBadge tier={c.tier} />80            </Td>81            <Td label="Extractor" className="mono text-xs text-ink-2">{c.extractor}</Td>82            <Td label="Claim">83              {c.id === currentId ? <span className="mono text-[11px] text-ink-3">this claim</span> : <Link href={routes.claim(c.id)} className="mono text-[11px] text-accent hover:underline">{c.id}</Link>}84            </Td>85          </tr>86        ))}87      </tbody>88    </DataTable>89  );90}9192export default async function ClaimPage({ params }: Params) {93  const { id } = await params;94  const c = await loadClaim(id);95  if (!c) {96    return (97      <Container>98        <div className="py-16">99          <Unavailable what="Claim" />100        </div>101      </Container>102    );103  }104  const cl = c.claim;105  const e = c.entity;106  const crumbs = [{ name: SITE_NAME, href: '/' }, ...(e ? [{ name: e.name, href: routes.entity(e) }, { name: 'History', href: routes.entityHistory(e, c.property) }] : []), { name: `Claim · ${propertyLabel(c.property)}`, href: routes.claim(id) }];107  const chain = c.chain ?? { previous: [], superseding: [], conflicting: [], history_count: 0 };108  const lifecycle: ClaimRow[] = [...chain.previous, cl, ...chain.superseding].filter((x, i, arr) => arr.findIndex((y) => y.id === x.id) === i).sort((a, b) => (a.valid_from < b.valid_from ? -1 : 1));109110  return (111    <Container wide>112      <BreadcrumbLd items={crumbs} />113      <Breadcrumbs items={crumbs} />114      <PageHeader115        eyebrow={116          <>117            Claim {e && <EntityBadge type={e.entity_type} small />} <StatusChip s={cl.status} />118          </>119        }120        title={121          <>122            {e ? (123              <Link href={routes.entity(e)} className="hover:text-accent">124                {e.name}125              </Link>126            ) : (127              'Entity'128            )}{' '}129            · <span className="text-ink-2">{propertyLabel(c.property)}</span> = <span className="tnum">{fmtValue(cl.value, c.property)}</span>130            {cl.unit && <span className="text-ink-3"> {cl.unit}</span>}131          </>132        }133        lede="A temporal claim: who said it, when it was observed, how it was extracted, how long it has been current, and what came before and after it. Claims are never overwritten — a better source supersedes, a worse one conflicts."134        aside={135          <p className="mono text-xs text-ink-3">136            {cl.id}137          </p>138        }139      />140141      <div className="grid gap-10 pb-16 lg:grid-cols-[minmax(0,1fr)_24rem]">142        <div className="min-w-0">143          <Section id="lifecycle" eyebrow="Lifecycle" title={<>Lifecycle <span className="tnum text-base font-normal text-ink-3">{fmtInt(chain.history_count)} in history</span></>} lede="Previous values, this claim, and the claims that superseded it — in validity order." hairline={false} className="pt-0">144            <ClaimTable rows={lifecycle} property={c.property} currentId={cl.id} caption="Lifecycle" />145          </Section>146          <Section id="conflicts" eyebrow="Conflicts" title={<>Conflicting claims <span className="tnum text-base font-normal text-ink-3">{fmtInt(chain.conflicting.length)}</span></>} lede="Values stated by other sources that disagree with the current one. Stored side by side, flagged for review, never averaged.">147            <ClaimTable rows={chain.conflicting} property={c.property} currentId={cl.id} caption="Conflicting claims" />148          </Section>149          {c.note && <Note className="mt-4">{c.note}</Note>}150        </div>151        <aside className="min-w-0 space-y-8">152          <section>153            <p className="eyebrow mb-2">Source</p>154            <dl className="kv [&>div]:py-1.5 text-sm">155              <div>156                <dt>Name</dt>157                <dd>{c.source?.name ?? <span className="text-ink-3">—</span>}</dd>158              </div>159              <div>160                <dt>Domain</dt>161                <dd className="mono text-xs">{c.source?.domain ?? '—'}</dd>162              </div>163              <div>164                <dt>URL</dt>165                <dd>166                  {c.source?.url ? (167                    <a href={c.source.url} target="_blank" rel="noopener noreferrer" className="link break-all text-xs">168                      {c.source.url.replace(/^https?:\/\//, '').slice(0, 80)}169                    </a>170                  ) : (171                    '—'172                  )}173                </dd>174              </div>175              <div>176                <dt>Tier</dt>177                <dd>178                  <TierBadge tier={c.source?.tier ?? cl.tier} withLabel />179                </dd>180              </div>181              <div>182                <dt>Observed</dt>183                <dd className="tnum">{fmtDateTime(c.source?.observed_at ?? cl.observed_at)}</dd>184              </div>185              {cl.effective_at && (186                <div>187                  <dt>Effective</dt>188                  <dd className="tnum">{fmtDateTime(cl.effective_at)}</dd>189                </div>190              )}191            </dl>192          </section>193          <section>194            <p className="eyebrow mb-2">Extraction</p>195            <dl className="kv [&>div]:py-1.5 text-sm">196              <div>197                <dt>Extractor</dt>198                <dd className="mono">{c.extractor?.name ?? cl.extractor}{c.extractor?.version ? <span className="text-ink-3"> v{c.extractor.version}</span> : null}</dd>199              </div>200              <div>201                <dt>Confidence</dt>202                <dd>203                  <ConfidenceBadge confidence={c.extractor?.confidence ?? cl.confidence} />204                </dd>205              </div>206              <div>207                <dt>208                  Run id <Hint align="right" text="Batch identifier of the connector run that wrote this claim. Admins can roll back a whole run." />209                </dt>210                <dd className="mono text-xs">{c.run_id ?? cl.run_id ?? <span className="text-ink-3">— (written before run tracking)</span>}</dd>211              </div>212              <div>213                <dt>Snapshot id</dt>214                <dd className="mono text-xs">{c.evidence?.snapshot_id ?? c.source?.snapshot_id ?? '—'}</dd>215              </div>216              {cl.value_raw !== null && cl.value_raw !== undefined && (217                <div>218                  <dt>Raw value</dt>219                  <dd className="mono text-xs">{String(cl.value_raw)}</dd>220                </div>221              )}222            </dl>223          </section>224          <section>225            <p className="eyebrow mb-2">Evidence</p>226            {c.evidence ? (227              <dl className="kv [&>div]:py-1.5 text-sm">228                <div>229                  <dt>Document</dt>230                  <dd>231                    {c.evidence.document_url ? (232                      <a href={c.evidence.document_url} target="_blank" rel="noopener noreferrer" className="link break-all text-xs">233                        {c.evidence.document_title ?? c.evidence.document_url.replace(/^https?:\/\//, '').slice(0, 60)}234                      </a>235                    ) : (236                      '—'237                    )}238                    {c.evidence.doc_type && <span className="block text-[11px] text-ink-3">{c.evidence.doc_type}</span>}239                  </dd>240                </div>241                <div>242                  <dt>Snapshot observed</dt>243                  <dd className="tnum">{fmtDateTime(c.evidence.snapshot_observed_at)}</dd>244                </div>245                <div>246                  <dt>Archived</dt>247                  <dd>{c.evidence.archived ? <Chip tone="accent">yes</Chip> : <Chip>no</Chip>}</dd>248                </div>249              </dl>250            ) : (251              <p className="text-sm text-ink-3">No evidence pointer.</p>252            )}253            <div className="mt-3 flex flex-wrap gap-2 text-xs">254              {e && (255                <Link href={routes.entityHistory(e, c.property)} className="inline-flex h-8 items-center border border-rule px-2 text-ink-2 hover:border-rule-strong hover:text-ink">256                  Full history of {propertyLabel(c.property).toLowerCase()} →257                </Link>258              )}259              {(c.evidence?.snapshot_id ?? c.source?.snapshot_id) && (260                <Link href={`/admin/extractions/${encodeURIComponent(c.evidence?.snapshot_id ?? c.source?.snapshot_id ?? '')}`} className="inline-flex h-8 items-center gap-1 border border-dashed border-rule px-2 text-ink-3 hover:border-rule-strong hover:text-ink" title="Administrators only: raw archived content and the extraction pipeline for this snapshot">261                  View archived snapshot <span className="rounded-[3px] bg-surface-2 px-1 text-[10px] uppercase">admin</span>262                </Link>263              )}264            </div>265            <Note className="mt-2">Raw archived content is available to administrators only; the public evidence is the pointer (snapshot id, document, observation time). <Link href={routes.methodology()} className="link">Methodology →</Link></Note>266          </section>267        </aside>268      </div>269    </Container>270  );271}272